feat(#912): MC/DC structural coverage over synth's own decision logic (RQ-57-MCDC) - #978
Merged
Merged
Conversation
RQ-57-MCDC. #912 sat N/A for six releases on a surface argument: `witness` measures MC/DC on a Wasm artifact and synth emits ARM/RV32/A64 machine code, so "run witness on synth's output" is a category error. That is true about synth's OUTPUT and irrelevant — the decisions that ship a miscompile are in synth's own Rust, and those compile to Wasm fine. SURFACE, chosen on evidence (scripts/repro/mcdc_912_gate.md): * REJECTED native LLVM MC/DC — not a preference, the capability was REMOVED from rustc (rust-lang/rust#144999, merged 2025-08-08, Rust 1.91). Probed on three nightlies: `mcdc` is rejected, only `block|branch|condition` accepted. `condition` is the trap in the family — it builds, and it emits ZERO `llvm.instrprof.mcdc.*` intrinsics and zero mcdc_records. * REJECTED witness-over-the-fixtures-synth-compiles — it measures the fixture. gale's run named `pad_integral`, `do_count_chars`, `<u64 as Display>::fmt`, `write_prefix`, `cabi_realloc` and ZERO synth functions. A surface whose gap rows cannot name a synth decision cannot notice a missing condition in one. * CHOSEN witness over a wasm32-wasip1 build of synth's OWN crates. `synth-mcdc-harness` is a thin row driver — it calls the REAL pub fns with inputs arriving through Wasm params; it re-implements no predicate. THE RIGHT PARTS — scored where a missed condition HAS shipped a bug here: static_data_addr (VCR-VER-003 #777/#757/#798), alloc_validator (VCR-RA-003 #815; #871's fix WAS a condition added to the save-set predicate), and the RV32 bounds gate (#953/#959 — `mem_size == 0` was exempt from the power-of-two check, so `(memory 0)` emitted an identity mask and every access ran unmasked). EXCLUDED and named: instruction_selector (225 operator lines, its own lane); the aarch64 bounds closures (#865 — residual); wcet declines (match-dispatch, 1 operator line in 1061, so MC/DC has nothing to say there). MEASURED 20 decisions / 144 conditions / 63 proved / 31 gap / 50 dead, 3 at full MC/DC. Identical under witness 0.28.0 and 0.42.0. Gap rows are PRINTED with the closing vector — `is_ret` went 3-gap to 0-gap by adding exactly the three vectors witness named. THE GATE SCORES BY FUNCTION AND FLOORS COUNTS, for three reasons: the module-wide figure is 3/770 because wasip1 links wasi-libc + std; witness's `source_file` is an inlined-DWARF BASENAME and misattributes (resolve_owner's decision reports as static_data_addr.rs:355, it is at :274 — witness#179); and a ratio cannot notice a DELETED condition, which makes the percentage improve. RED-FIRST, two mutations, two distinct failure paths, both restored byte-identical: (a) delete `|| rs2 == Reg::RA` (the #871 fix) -> 19/142/54/2, all four floors trip; the condition-COUNT drop is what a ratio cannot produce. (b) drop one truth-table row (ra_validate:14) -> conditions stay 144, proved 63->62 and fully-proved 3->2. Wired as the `mcdc-structural-coverage` CI job, witness pinned to v0.42.0, with a non-vacuity assert so an empty scored table cannot print PASS. Refs #912 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
This was referenced Aug 14, 2026
… dead count Three fixes from the first CI run and the cold read of it: 1. exit 126. `scripts/` is mode 644 in this repo (cf. scripts/oracle_run.py), so `./scripts/mcdc_run.sh` cannot exec on a fresh checkout even though it runs locally after a chmod. Invoke through `bash`, matching how every other script in this workflow is called. 2. PIN the toolchain to 1.96.1. The floors are counts of decisions and conditions RECONSTRUCTED FROM LOWERED WASM, so they are sensitive to how `std` inlines. `@stable` is a moving target and a Rust release could red this gate with no code change. Bumping the pin is allowed and obliges a RE-MEASURE, not a lowering. (Witness-version invariance — 0.28 = 0.42 — is evidence about witness, not about rustc; it does not transfer.) 3. CEILING the dead count at the measured 50. 50 of 144 scored conditions are never evaluated (40 in `is_straight_line`, whose match arms cover RV32 opcodes the row set does not construct). That residual was the one number in the table nothing guarded: a change that stopped reaching the segment barriers would raise `dead`, lower nothing else, and PASS. It is also a third potency surface — mutation (a) moves dead 50 -> 52, so the mutated run now trips five floors instead of four. Also trims the `sd_resolve_owner` doc comment, which claimed vectors the measurement does not show: its 2 gap conditions are now stated as a measured residual rather than implied closed. Baseline re-verified unchanged: 20 / 3 / 144 / 63 / 31 / 50, PASS. Refs #912 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
Reverted by the very next commit. Two things in one CI run:
1. RE-MEASURE the floors on the platform the gate actually runs on. The first
CI run proved what could only be argued locally: these counts are
PLATFORM-dependent, not just toolchain-version dependent.
ubuntu x86_64, rustc 1.96.1, witness 0.42.0: 22 / 130 / 57 / 23 gap / 50 dead, 4 full
macOS aarch64, rustc 1.96.1, witness 0.42.0: 20 / 144 / 63 / 31 gap / 50 dead, 3 full
Same toolchain version, same witness, same 56 rows, different host.
`validate_final_allocation_rv32` presents as 9 decisions / 44 conditions on
Linux and 4 / 43 on macOS; `ensure_supported_target` vanishes on Linux.
Floors move to the CI numbers — RE-MEASURED, not lowered to fit. Both
baselines are recorded in the script so the macOS delta is a stated fact
rather than a surprise.
2. RE-PROVE POTENCY ON THAT PLATFORM. The local mutation evidence does NOT
obviously transfer when the same function presents as 9 decisions instead
of 4 — and "it obviously still works" is the reasoning this whole lane
exists to distrust. So this commit ALSO deletes the #871 condition
`|| rs2 == Reg::RA` from the RV32 allocation validator's save-set
predicate. The MC/DC job must go RED on the condition-COUNT floor, and the
VCR-RA-003 RV32 job should go red independently.
If this commit is green, the gate is vacuous and the lane is wrong.
Refs #912
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
…mmit Probe 1 (25a9195) went RED on CI as required, but at the WRONG STEP for the evidence I wanted: the row-driver sanity gate (step 7) asserts `ra_validate(4) == 1` (#871: unsaved ra must be a violation), so deleting `|| rs2 == Reg::RA` fails there and the MC/DC measurement never runs. Two independent gates catching one mutation is good engineering and useless as proof that the MC/DC FLOORS bite. This probe isolates them. It restores the source byte-identical (verified: empty `git diff` vs the pre-mutation tree) and instead drops ONE truth-table row — `ra_validate:14`, the non-sp `Lw` that gives `sp_slot_load` its unique-cause pair. That touches no compiler behaviour, so: step 7 (row-driver sanity) must PASS step 8 (witness run) must PASS step 9 (MC/DC floors) must FAIL on and Conditions must stay at 130 — coverage lost, nothing deleted. That is the second of the two potency paths, and the one that proves the SCORING floor itself is what goes red, on the platform it actually runs on. Refs #912 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
Both red-first probes reverted; the tree is byte-identical to 712f658 for crates/synth-backend-riscv/src/alloc_validator.rs and scripts/mcdc_run.sh (verified with an empty \$(git diff 712f658 -- <path>) on each). What the two CI probes established, which the local run could not: PROBE 1 (delete the #871 condition) went RED — at the WRONG STEP. The job's own row-driver sanity gate asserts ra_validate(4) == 1 and fails there, so the MC/DC measurement never ran; the VCR-RA-003 RV32 job went red independently too. Three gates catching one mutation is good engineering and NO evidence that the MC/DC floors bite. Recording it because 'a red gate is not evidence that the gate you were testing works' generalises well past this lane — read WHICH STEP failed. PROBE 2 (drop one truth-table row, no compiler behaviour touched) isolated them. Run 31821746035: sanity gate PASS, witness run PASS, SCORING STEP FAIL. baseline 22 4 130 57 23 50 probe 2 22 3 130 56 24 50 FAIL: proved conditions 56 < floor 57 FAIL: fully-proved decisions 3 < floor 4 Decisions unchanged, CONDITIONS UNCHANGED at 130 — nothing deleted — while proved fell and a decision dropped out of full MC/DC. That is the signature of coverage lost with structure intact, produced by the MC/DC scoring step itself, on the platform the gate blocks on. Refs #912 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
avrabe
added a commit
that referenced
this pull request
Aug 14, 2026
…e 4 shipped (#979) Two things. The re-grades are bookkeeping; the back-fill is a finding. RE-GRADED to `implemented` (work merged on main): RQ-57-COUNTPARAMS #970 (#974) ARM+RV32 conditional-param miscompile RQ-57-GPIO #846 (#976) gpio-thin 502 -> 494 B RQ-57-ARMSEM #923 (#975) ArmSemantics no-oped 87 of 222 ops RQ-57-MCDC #912 (#978) MC/DC over synth's own decision logic RQ-57-BACKFILL: the back-fill was NOT performed, on evidence. 263 of 288 artifacts carry no `release:`. The prescribed derivation — "the first tag containing the artifact" — RUNS FINE (0 undecidable, 24 distinct tags) and answers the WRONG QUESTION. It yields when an artifact ENTERED THE PLAN; `release:` means the release the work is TARGETED AT or SHIPPED IN. The file supplied its own control case, which is what settles it: VCR-RA-001, hand-set release: v0.24.0 <- when the work shipped VCR-RA-001, mechanical rule v0.11.30 <- introducing commit's tag It is the ONLY artifact in verified-codegen-roadmap.yaml that already carried a `release:`, and the rule contradicts it. Sweeping the other 32 would have written 32 false values with the one correct value sitting beside them as the disproof. Scale, had it been applied blindly: 190 of the 263 resolve to v0.1.1 — the initial import, i.e. the standing requirement base (architecture, stakeholder and system requirements, component model, target platforms). Tagging those v0.1.1 asserts the whole foundational base was targeted at the first tag, and makes "what is in v0.1.1?" return 190 artifacts including work that shipped forty releases later. That is the corruption of the readiness query this artifact exists to prevent — so the artifact's own guardrail ("stays unassigned rather than guessed", the #911 lesson applied to planning data) decides it. CONVENTION, now documented in docs/release-process.md so the absence stops being re-filed as an unfinished chore: * per-release plan artifacts carry `release:` (they are work items; they do) * standing artifacts carry it ONLY where the shipping release is known, as VCR-RA-001 does * setting it on a standing artifact is a per-artifact judgement with CHANGELOG evidence, never a sweep A missing `release:` is a justified state. A wrong one is worse than a missing one. rivet: 50 errors / 166 warnings before AND after — unchanged. claim_check 43/43. Refs #912 Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
avrabe
added a commit
that referenced
this pull request
Aug 18, 2026
#978 pinned the MC/DC job to `dtolnay/rust-toolchain@1.96.1` for a stated reason: its floors (decisions / conditions / proved / dead) are derived from that exact compiler on the CI host, and the pin exists so a Rust release cannot red the gate with no code change. #984 bumped it to @1.100.0 — as if it were a routine action-version update. FOR THIS ACTION THE REF IS THE COMPILER. So a bot bump that reads like "dependabot: bump action from X to Y" silently changes which rustc the MC/DC surface is built with, and every subsequent run measures a DIFFERENT compiler against 1.96.1-derived floors. The pin was defeated by the one update shape nobody inspects. It is already biting: PR #994 (this branch, before this commit) failed exactly one check — MC/DC — for no reason other than being based on a main that now installs 1.100.0. Two changes: * .github/workflows/ci.yml — restored @1.96.1, with the reason inline at the pin so the next reader does not have to reconstruct it from two issues. * .github/dependabot.yml — `ignore: dtolnay/rust-toolchain` for the github-actions ecosystem. Bumping it is a deliberate act that must RE-MEASURE the floors, which is not a bot's job. This is the same class as the 0.x-minor rule (#849/#965): an update whose CATEGORY is wrong, so the automation's category-based judgement is wrong too. There the fix was "hold 0.x-minor because minor IS major for 0.x"; here it is "this action's ref is not an action version at all". claim_check 47/47. Refs #242, #912, #978 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
RQ-57-MCDC. Closes the six-release N/A on #912 by answering the question it was
actually stuck on: which surface.
The surface, decided on evidence
#912's stated blocker was structural —
witnessmeasures MC/DC on a Wasmartifact, synth emits ARM/RV32/A64 machine code, so "run witness on synth's
output" is a category error. That is correct about synth's output and beside
the point: the decisions that ship a miscompile are in synth's own Rust, and
those compile to Wasm fine.
REJECTED — native LLVM MC/DC via
-Zcoverage-options=mcdc. Not apreference; the capability was removed from rustc.
rust-lang/rust#144999 —
"coverage: Remove all unstable support for MC/DC instrumentation", merged
2025-08-08, Rust 1.91. Probed rather than assumed, on three nightlies
(1.93/1.95/1.97-nightly):
mcdcis rejected, onlyblock|branch|conditionisaccepted.
conditionis the trap in that family — it builds, and yieldsMC/DC Conditions 0,mcdc_records: 0, and zerollvm.instrprof.mcdc.*intrinsics in the emitted IR.
cargo-llvm-cov0.6.21 and 0.8.7 both stillpass
mcdcand both fail the build.REJECTED — witness over the Wasm fixtures synth compiles. It measures the
fixture. The discriminating test is whose source the gap rows name; gale's run
(on #912) named
core::fmt::Formatter::pad_integral,core::str::count::do_count_chars,<u64 as Display>::fmt,pad_integral::write_prefix,wit_bindgen::rt::cabi_realloc— fivestdlib/bindgen frames and zero synth functions. A surface whose gap rows
cannot name a synth decision cannot notice a missing condition in one.
CHOSEN — witness over a
wasm32-wasip1build of synth's OWN crates.crates/synth-mcdc-harnessis a thin row driver: it links the real cratesand calls the real
pub fns with inputs arriving through Wasm parameters. Itre-implements no predicate — a mirror would be exactly the vacuous-gate class
this hub keeps finding. Gap rows name
synth_core::static_data_addr::resolve_owner,synth_backend_riscv::alloc_validator::is_ret, and so on.Unexpected upside: witness reconstructs decisions from the lowered
br_ifchains, so a Rust
matches!becomes a real multi-condition decision.is_ret'smatches!(op, Jalr { rd: ZERO, rs1: RA, imm: 0 })scores as4 conditions — source-level MC/DC would have seen nothing there.
"The right parts"
Scored, because a missed condition here has already shipped a soundness bug in
this repo:
synth_core::static_data_addrsynth_backend_riscv::alloc_validatorramiscompile and the fix was a condition added to the save-set predicatesynth_backend_riscv::backendmem_size == 0was EXEMPT from the power-of-two mask gate, so(memory 0)emitted an identity mask (0-1 = 0xFFFF_FFFF) and every access ran unmaskedExcluded, named not hidden:
synth_synthesis::instruction_selector— 225 boolean-operator lines; its ownlane, and already gated by ~30 execution differentials.
bounds_check/form_eaclosures (aarch64: linear-memory load/store emit NO bounds check and --safety-bounds is a no-op (all modes byte-identical) — OOB reads/writes up to 4GiB past the guest memory instead of trapping #865) — reachable only bydriving a whole function body through the selector. Residual: the
guard-emission class is covered on RV32, not on aarch64.
synth_backend::wcet*decline predicates —scan_for_declinehas 1boolean-operator line in 1061; it is match-dispatch, so MC/DC has nothing to
say about most of it. Branch coverage is the applicable criterion there.
Measured — and the platform turned out to be part of the measurement
witness 0.42.0,
wasm32-wasip1, rustc 1.96.1, the same 56 rows, two hosts:Same toolchain version, same witness, same rows — different host. These
count decisions reconstructed from lowered Wasm, so
stdinlining movesthem:
validate_final_allocation_rv32presents as 9 decisions / 44 conditionson Linux and 4 / 43 on macOS, and
ensure_supported_targetdisappears on Linuxentirely. Only
deadis identical (50), as you'd expect of "never reached".That was not predicted — it was measured, by the first CI run, after the
local baseline had been written down. Floors are therefore the CI numbers
(re-measured, not lowered to fit), with both baselines recorded in the script so
the macOS delta is a stated fact rather than a surprise: a developer running
this locally on macOS will not meet these floors, and that is a platform delta,
not a regression.
Witness-version invariance was checked separately (0.28.0 ≡ 0.42.0 on one host),
so the tool is not what moves these.
The gate reads gap rows, not a percentage. All 31 are printed with the
vector witness says would close them. Two residuals stated explicitly:
validate_final_allocation_rv32carries 10- and 20-condition whole-functionchains (≥21 co-designed vectors each; not claimed), and
ensure_supported_target's ISA conjunction cannot be flipped through publicconstructors (no
TargetSpechas family RiscV with a non-RiscV32/64 ISA).is_retwent 3-gap → 0-gap by adding exactly the three vectors witnessprinted — the gap rows are actionable, not decorative.
Why the gate scores by FUNCTION and floors COUNTS
wasi-libc + std, so the raw figure is
3/770with 3879 dead conditions.source_file/source_linecannot be used for scoping — theyare DWARF attributions of inlined code (
resolve_owner's decision reportsas
static_data_addr.rs:355; it is at :274) and only a basename, so sixcrates'
backend.rscollide (upstream witness#179). The manifest'sper-branch
function_nameis reliable, so the gate scopes on thedemangled symbol.
row and the percentage improves.
Declared floors = the CI platform's measured baseline, no slack:
decisions ≥ 22,conditions ≥ 130,proved ≥ 57,fully-proved ≥ 4,dead ≤ 50.Dead is ceilinged, not ignored. 50 scored conditions are never
evaluated — 40 in
is_straight_line, whose match arms cover RV32 opcodes therow set does not construct. Honest residual, but an unfloored residual is how
a number rots: a change that stopped reaching the segment barriers would raise
dead, lower nothing else, and pass.Red-first potency — and the finding that a red gate proved nothing
Locally (macOS baseline
20 / 3 / 144 / 63 / 31 / 50), both restored byte-identical(a) Delete a condition — remove the #871 fix
|| rs2 == Reg::RA:The condition-count drop is the signal a ratio-only floor cannot produce.
(b) Weaken the vector set — drop ONE row (
ra_validate:14):Then on CI, where the gate actually blocks — because the local result does not obviously transfer when the same function presents as 9 decisions instead of 4
Probe 1 (mutation a) went red at the WRONG STEP. The job's own row-driver
sanity gate asserts
ra_validate(4) == 1(#871), so deleting the conditionfails there and the MC/DC measurement never ran;
VCR-RA-003 RV32went redindependently too. Three gates catching one mutation is good engineering and
no evidence that the MC/DC floors bite. Worth recording as a general
lesson: a red gate is not evidence that the gate you were testing works — read
which step failed.
Probe 2 (mutation b) isolated them — dropping a truth-table row touches no
compiler behaviour, so the failure must come from the scoring step or not at
all. Run
31821746035:Decisions unchanged, conditions unchanged at 130 — nothing deleted — while
provedfell and one decision dropped out of full MC/DC. Coverage lost,structure intact, caught by the MC/DC scoring step itself, on the platform the
gate blocks on.
Both probes are reverted;
git diff 712f658d -- crates/is empty andscripts/mcdc_run.shis byte-identical to the pre-probe tree.Wiring
mcdc-structural-coverageCI job, witness pinned to v0.42.0, with anon-vacuity assert (
grep -qE '^TOTAL +[0-9]+') so an empty scored table cannotprint PASS. A host-side row-driver sanity test runs under
cargo test --workspace,so a driver that returned one verdict for every row would go red rather than
quietly make the whole MC/DC run vacuous.
The toolchain is pinned to 1.96.1, deliberately unlike every other job here:
the floors are counts of decisions and conditions reconstructed from lowered
Wasm, so they are sensitive to how
stdinlines, and@stableis a movingtarget that could red the gate with no code change. Bumping the pin is allowed
and obliges a re-measure, not a lowering. (The witness-version invariance
0.28 ≡ 0.42 is evidence about witness; it does not transfer to rustc.)
The CI runs caught three real things, recorded because they are the kind of
thing that otherwise ships:
./scripts/mcdc_run.shexited 126 on a freshcheckout (
scripts/is mode 644 here, cf.oracle_run.py; it only ran locallybecause of a
chmod); the job had been on@stable; and the floors werehost-dependent in a way no amount of local measurement would have revealed.
Gates
cargo test --workspace0 ·cargo clippy --workspace --all-targets -- -D warnings0 ·cargo fmt --check0 ·claim_check.py43/43 (no new load-bearing doc claim,so the ledger is unchanged).
Full evidence:
scripts/repro/mcdc_912_gate.md.Refs #912
🤖 Generated with Claude Code
https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L